🐛 fix(proxy): guard runtime helpers against DNS-rebinding#4518
Conversation
The proxy SecurityPolicy (default AllowPrivateIPs=false) is documented as blocking SSRF against loopback, RFC 1918, link-local, CGNAT, and cloud metadata addresses. That guarantee only truly held for Balancer, which installs a dial-time guard (newSSRFDialer) that re-validates the resolved IP atomically with the connection. The runtime helpers — Do, DoRedirects, DoTimeout, DoDeadline, Forward, DomainForward, BalancerForward — validated the host only once, up front, then dispatched through a shared/user-supplied *fasthttp.Client whose own dialer re-resolved the hostname a second time with no validation. A rebinding resolver returning a public IP on the first lookup and a private IP on the second passed the check and then connected to the private address anyway. Install a policy-aware, dial-time SSRF guard on every *fasthttp.Client the runtime helpers dispatch through: - newGuardedClientDialer wraps an existing dialer: when AllowPrivateIPs is true it delegates (preserving prior behavior); otherwise it resolves, validates against the blocklist, and dials the exact validated IP, composing with any caller-supplied dialer. It reuses resolveAndValidateHost and dialValidatedIPs and reads the active policy at dial time. - ensureClientGuarded wraps each client's Dial exactly once (mutex + seen set), wired into init (default client), WithClient (global override), and doActionWithPolicy (per-call variadic clients) before any dispatch, so the field is never mutated concurrently with a dial. The only path left to the caller is a custom Balancer Config.Client (*fasthttp.LBClient), whose underlying dialers are not reachable for wrapping; this is documented. Adds a regression test that drives a fake rebinding resolver (public answer first, loopback after) and asserts the internal server is never reached, for both the default and a custom client, plus unit tests for the new dialer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSa22vkkDLDTZctuKkmZ1A
…ypass, leak, and lock Code review of the initial dial-time guard surfaced several defects in how the guard was attached to the dispatching *fasthttp.Client. Re-work the attachment to use fasthttp's ConfigureClient hook, which runs once per HostClient at creation, and address the findings: - DialTimeout bypass (SSRF): fasthttp's callDialFunc prefers a client's DialTimeout over Dial, so guarding only Dial left DialTimeout-configured clients completely unvalidated. installHostClientGuard now wraps both DialTimeout (preserving its per-request timeout) and Dial. - Timing: installing at HostClient creation (ConfigureClient) means the guard is present before the first dial to each host, instead of mutating Client.Dial after HostClients may already have been cached with the old dialer. The default client (init) and WithClient clients (before use) are fully guarded. - Memory/resource leak: the previous global map keyed by *fasthttp.Client pinned every client (and its connection pool) forever. Guarding is now idempotent by identity (a stable package-level ConfigureClient hook), so the common case retains no client reference; only clients that already carry their own ConfigureClient use a sync.Map, composed once. - Hot-path lock: dropped the process-global mutex acquired on every proxy request in favor of the lock-free identity check. Refactor the dialer into a shared guardedDial core with DialFunc and DialFuncWithTimeout variants. Add regression tests: a custom client that sets DialTimeout is now blocked under DNS-rebinding, plus unit coverage for ConfigureClient composition/idempotency and DialTimeout guarding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSa22vkkDLDTZctuKkmZ1A
Code review of the ConfigureClient-based guard found three defects: - Compose order (SSRF re-open): the composed hook ran installHostClientGuard before the user's existing ConfigureClient, so a user hook that sets hc.Dial/hc.DialTimeout (the idiomatic reason to use ConfigureClient) overwrote the guard. Run the user's hook first, then install the guard, so it wraps whatever dialer the user finally sets. - TOCTOU / data race: guarding published a "handled" flag before the guard field was written, letting a concurrent caller dispatch through a still-unguarded client; the field write was also unsynchronized. Serialize the whole read-modify-write under a mutex so the guard is installed before any other guarder observes the client. - Hot-path cost: only guard the per-call variadic client on dispatch; the global/default client is guarded at registration, so the common no-variadic request path no longer touches the guard lock. Tests: strengthen the compose test so it fails on the wrong order (the user hook now installs its own hc.Dial and the guard must still block loopback and delegate an allowed IP); add a concurrency test (race-detector regression guard for the mutex); add a positive-path test that a validated hostname resolves and dials through to the exact IP; add a DoRedirects rebinding case; and key the fake resolver on the query name so a stray lookup cannot mask a broken dial-time guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSa22vkkDLDTZctuKkmZ1A
Follow-up review hardening: - The fake resolver now counts A queries and returns the counter; the rebinding test asserts >= 2 lookups, proving the block happens at dial time (a second, rebound lookup) rather than up front — so a broken dial-time guard masked by an up-front block can no longer pass silently. - Make the guard docs precise: clients with a nil ConfigureClient (the common case) are matched by identity and never retained; only a client carrying its own ConfigureClient is stored (one bounded entry per distinct client), with guidance to reuse such clients or register them via WithClient. Note that the per-request guardMu on a fixed variadic client is a deliberate correctness-over-throughput choice. No behavior change; verified with go test -race. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSa22vkkDLDTZctuKkmZ1A
Defense-in-depth from the final review pass. isBlockedIP already caught the IPv4-mapped form (::ffff:a.b.c.d, which net.IP.To4 surfaces) but not the IPv4-compatible (::a.b.c.d) or NAT64 well-known-prefix (64:ff9b::a.b.c.d) forms, so a literal like ::127.0.0.1 or 64:ff9b::a9fe:a9fe (the metadata address) resolved to false. Some stacks route these to the embedded IPv4, so unwrap them and apply the same blocklist to the embedded address. Public embedded addresses stay allowed. Table test extended with mapped, compatible, and NAT64 cases (private blocked, public allowed). Also correct the DomainForward doc: it validates (and resolves) the addr at construction, which — unlike Balancer, which defers DNS — panics at startup on an unresolvable addr. The previous "matches the Balancer contract" wording was imprecise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSa22vkkDLDTZctuKkmZ1A
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughDial-time SSRF protection now guards shared and per-call ChangesSSRF and DNS-rebinding protection
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ProxyDispatch
participant FasthttpClient
participant HostClient
participant Resolver
ProxyDispatch->>FasthttpClient: Dispatch upstream request
FasthttpClient->>HostClient: Open connection
HostClient->>Resolver: Resolve hostname at dial time
Resolver-->>HostClient: Return current IP addresses
HostClient->>HostClient: Validate resolved IP
HostClient-->>FasthttpClient: Block or dial validated IP
FasthttpClient-->>ProxyDispatch: Return response or error
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.12.2)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4518 +/- ##
==========================================
+ Coverage 93.13% 93.22% +0.09%
==========================================
Files 140 140
Lines 14275 14421 +146
==========================================
+ Hits 13295 13444 +149
+ Misses 611 608 -3
Partials 369 369
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR hardens middleware/proxy against DNS-rebinding SSRF by ensuring the runtime helper paths (e.g., Do, Forward, DoRedirects) re-validate resolved IPs at dial time (not only at initial validation), aligning their security guarantees with the Balancer path.
Changes:
- Add a policy-aware, dial-time SSRF guard that wraps
fasthttp.HostClientdialing (DialandDialTimeout) and install it viafasthttp.Client.ConfigureClient, with idempotent client guarding. - Extend the upstream IP blocklist to also catch IPv4-compatible and NAT64 IPv6 wrapper literals by unwrapping embedded IPv4s before evaluation.
- Add regression/unit tests covering DNS rebinding behavior and guarded dialer composition (plus expanded
isBlockedIPcases).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| middleware/proxy/security.go | Implements dial-time guarded dialing helpers and enhances blocked-IP detection for IPv6 wrapper forms. |
| middleware/proxy/proxy.go | Ensures all runtime-helper-dispatched *fasthttp.Clients get guarded via ConfigureClient, including WithClient and per-call overrides. |
| middleware/proxy/security_test.go | Expands isBlockedIP test coverage for IPv4-mapped, IPv4-compatible, and NAT64 literals. |
| middleware/proxy/security_rebinding_test.go | Adds end-to-end rebinding regression and unit tests for the new guarded client dialer and guard installation semantics. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
middleware/proxy/security_rebinding_test.go (3)
347-355: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueSimplify goroutine spawning with
WaitGroup.Go(modernize/revive).♻️ Proposed fix
var wg sync.WaitGroup - for range 32 { - wg.Add(1) - go func() { - defer wg.Done() - ensureClientGuarded(cli) - }() - } + for range 32 { + wg.Go(func() { + ensureClientGuarded(cli) + }) + } wg.Wait()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/proxy/security_rebinding_test.go` around lines 347 - 355, Replace the manual wg.Add(1), goroutine wrapper, and defer wg.Done() around ensureClientGuarded(cli) with the WaitGroup.Go helper, while preserving the loop count and waiting behavior with wg.Wait().Source: Linters/SAST tools
163-170: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace the DialTimeout wrapper lambda with a direct reference (
unlambda).
fasthttp.DialTimeoutalready matches theDialTimeoutfield signature, so the wrapping closure is flagged by lint here and again at Line 374.♻️ Proposed fix
cli := &fasthttp.Client{ - DialTimeout: func(addr string, timeout time.Duration) (net.Conn, error) { - return fasthttp.DialTimeout(addr, timeout) - }, + DialTimeout: fasthttp.DialTimeout, }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/proxy/security_rebinding_test.go` around lines 163 - 170, Replace the redundant DialTimeout closure in the “custom client with DialTimeout” test case with a direct fasthttp.DialTimeout function reference, and apply the same unlambda change to the corresponding occurrence near the other flagged location.Source: Linters/SAST tools
93-104: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
buildDNSResponselint failures:hugeParam(Line 93) and unwrapped errors (Lines 101, 104).
qis a 260-byte value; passing by pointer avoids the copy. TheStartQuestions()/Question(q)returns are also flagged bywrapcheck. Consider passingqby pointer and wrapping (or//nolint-ing) the builder errors to clear the lint job.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/proxy/security_rebinding_test.go` around lines 93 - 104, Update buildDNSResponse to accept *dnsmessage.Question and pass the pointer through to the builder, updating callers as needed; wrap the errors returned by StartQuestions and Question with contextual messages (or add narrowly scoped nolint directives) to satisfy hugeParam and wrapcheck linting.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@middleware/proxy/security_rebinding_test.go`:
- Line 35: Add justified //nolint:errcheck comments with reasons to the
pc.Close(), pc.WriteTo(...), and ln.Close() calls in the relevant test cleanup
and write paths, preserving the existing behavior while satisfying errcheck.
---
Nitpick comments:
In `@middleware/proxy/security_rebinding_test.go`:
- Around line 347-355: Replace the manual wg.Add(1), goroutine wrapper, and
defer wg.Done() around ensureClientGuarded(cli) with the WaitGroup.Go helper,
while preserving the loop count and waiting behavior with wg.Wait().
- Around line 163-170: Replace the redundant DialTimeout closure in the “custom
client with DialTimeout” test case with a direct fasthttp.DialTimeout function
reference, and apply the same unlambda change to the corresponding occurrence
near the other flagged location.
- Around line 93-104: Update buildDNSResponse to accept *dnsmessage.Question and
pass the pointer through to the builder, updating callers as needed; wrap the
errors returned by StartQuestions and Question with contextual messages (or add
narrowly scoped nolint directives) to satisfy hugeParam and wrapcheck linting.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 831deb5d-f29d-44a9-b247-9f5e2321fd08
📒 Files selected for processing (4)
middleware/proxy/proxy.gomiddleware/proxy/security.gomiddleware/proxy/security_rebinding_test.gomiddleware/proxy/security_test.go
The Lint CI (golangci-lint v2.12.2) flagged the new test file. Fix all 13: - errcheck (check-blank): drop `_ =` on best-effort pc.Close/pc.WriteTo/ ln.Close and mark //nolint:errcheck, matching the repo convention. - wrapcheck: wrap the golang.org/x/net/dns/dnsmessage builder errors with fmt.Errorf (that package isn't in the ignore globs). - gocritic hugeParam: pass dnsmessage.Question by pointer to buildDNSResponse. - gocritic unlambda: use fasthttp.DialTimeout directly instead of a wrapper. - modernize/revive: use sync.WaitGroup.Go instead of Add/go/Done. No behavior change; go test -race still passes and golangci-lint is clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSa22vkkDLDTZctuKkmZ1A
… race) The macOS -race CI caught a data race: the rebinding test swaps the process-global net.DefaultResolver, while a leaked fasthttp dial goroutine from Test_Proxy_DoDeadline_PastDeadline reads it via net.Dialer.DialContext (reached through the guard's delegate path now that the default client is guarded). Mutating the stdlib global is inherently racy with background DNS lookups anywhere in the test binary. Add an atomic dnsResolver seam in the proxy package (defaults to net.DefaultResolver) used by validateHostForSSRF and resolveAndValidateHost, and have the test swap that atomically instead of net.DefaultResolver. Reads and the test's swaps are now both atomic, and net.DefaultResolver is never written, so the race class is eliminated by construction. Verified by running the two tests together under -race -count=20. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSa22vkkDLDTZctuKkmZ1A
…overage Address PR review feedback: - Document that a per-call variadic *fasthttp.Client is guarded only on first use (hosts it already dialed keep a cached, pre-guard HostClient), and that a full guarantee needs WithClient or a dedicated client before first use. Added to both the Do SSRF note and the SecurityPolicy.AllowPrivateIPs doc, which previously overstated the guarantee for custom clients. - Add unit tests covering the previously-uncovered guard branches flagged by codecov: compose-hook error propagation, malformed dial address, the dual-stack delegate path, and the DialFuncWithTimeout delegate + validated paths. ensureClientGuarded/guardedDial/installHostClientGuard/ newGuardedClientDialerWithTimeout are now fully covered. No behavior change; golangci-lint clean and go test -race passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSa22vkkDLDTZctuKkmZ1A
There was a problem hiding this comment.
⚠️ Performance Alert ⚠️
Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.50.
| Benchmark suite | Current: 4cfd4da | Previous: f280569 | Ratio |
|---|---|---|---|
BenchmarkStripHopByHop_NoConnection (github.com/gofiber/fiber/v3/middleware/proxy) |
1474 ns/op 0 B/op 0 allocs/op |
929.7 ns/op 0 B/op 0 allocs/op |
1.59 |
BenchmarkStripHopByHop_NoConnection (github.com/gofiber/fiber/v3/middleware/proxy) - ns/op |
1474 ns/op |
929.7 ns/op |
1.59 |
This comment was automatically generated by workflow using github-action-benchmark.
Address PR review feedback: - composedGuards was a package-level map keyed by *fasthttp.Client that retained every client carrying its own ConfigureClient, pinning those clients and their connection pools for the process lifetime (an unbounded leak under a fresh-client-per-request pattern). Replace it with a small guardedConfigureClient struct whose bound method (.run) is installed as the ConfigureClient hook: bound method values share a stable code pointer across receivers, so ensureClientGuarded still recognizes an already-guarded client by identity — but the struct is referenced only from the client's own field and is collected together with the client. No map, no retention. (Plain closures were verified not to share a code pointer, so a closure-based self-identifying wrapper would not work.) - Correct the newGuardedClientDialer doc: when orig is nil the delegate path uses fasthttp's default dialer, but the validated (blocked-policy) path dials the checked IP with a net.Dialer bounded by dnsLookupTimeout (matching newSSRFDialer); the previous wording implied fasthttp.Dial in both paths. Behavior unchanged; ensureClientGuarded and the guard method are fully covered, golangci-lint is clean, and go test -race passes (including the compose-ordering, idempotency, and concurrency regression tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSa22vkkDLDTZctuKkmZ1A
There was a problem hiding this comment.
🧹 Nitpick comments (1)
middleware/proxy/proxy.go (1)
178-213: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winAvoid using
reflect.Value.Pointer()as the idempotency key — compare against an explicit sentinel onguardedConfigureClientinstead; Go doesn’t guarantee func-pointer identity for bound method values, so this check can misclassify an already-guarded client.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/proxy/proxy.go` around lines 178 - 213, Replace the reflect.Value.Pointer-based check in ensureClientGuarded with an explicit sentinel identifying guardedConfigureClient hooks. Update guardedConfigureClient and its run hook so the sentinel is preserved or detectable for composed hooks, then use that sentinel to return early when the client is already guarded; remove the guardHookPtr dependency.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@middleware/proxy/proxy.go`:
- Around line 178-213: Replace the reflect.Value.Pointer-based check in
ensureClientGuarded with an explicit sentinel identifying guardedConfigureClient
hooks. Update guardedConfigureClient and its run hook so the sentinel is
preserved or detectable for composed hooks, then use that sentinel to return
early when the client is already guarded; remove the guardHookPtr dependency.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: f0d64a9d-7704-4890-9358-17c6e8db0f27
📒 Files selected for processing (2)
middleware/proxy/proxy.gomiddleware/proxy/security.go
🚧 Files skipped from review as they are similar to previous changes (1)
- middleware/proxy/security.go
Description
middleware/proxy'sSecurityPolicy(defaultAllowPrivateIPs: false) is documented as blocking SSRF against loopback, RFC 1918, link-local, CGNAT, and cloud-metadata addresses. In practice that guarantee only held forBalancer, which installs a dial-time guard (newSSRFDialer) that re-validates the resolved IP atomically with the connection.Every other public entry point —
Do,DoRedirects,DoTimeout,DoDeadline,Forward,DomainForward,BalancerForward— validated the host only once, up front, then dispatched through a shared/user-supplied*fasthttp.Clientwhose own dialer re-resolved the hostname independently, with no validation. A rebinding resolver that answers with a public IP on the first lookup and a private IP on the second passed the up-front check and then connected to the private address anyway (classic DNS rebinding / check-use gap).This PR closes that gap for all runtime helpers by giving them the same dial-time re-validation
Balanceralready has, soAllowPrivateIPs=falsegenuinely blocks rebinding for every entry point.Fixes the DNS-rebinding SSRF in
proxy.Do/Forward/DoRedirects/DoTimeout/DoDeadline/DomainForward/BalancerForward(☢️ Bug).How the guard is installed
The guard rides on fasthttp's
Client.ConfigureClienthook, which runs once perHostClientat creation — so it is present before the first dial to each host and, crucially, covers both dial code paths (callDialFuncprefersDialTimeoutoverDial, so guarding onlyDialwould leave aDialTimeout-configured client unvalidated).installHostClientGuardwraps bothhc.DialTimeout(preserving its per-request timeout) andhc.Dial.guardedDial(shared core) +newGuardedClientDialer/newGuardedClientDialerWithTimeout: whenAllowPrivateIPsis true the guard delegates unchanged; otherwise it resolves the host, rejects the whole resolution on any blocked IP, and dials the exact validated IP (through the caller's dialer when set). Policy is read fresh on every dial, since a client outlives any single policy snapshot. ReusesresolveAndValidateHostanddialValidatedIPs.ensureClientGuardedinstalls the hook idempotently, serialized under a mutex so the guard is in place before any concurrent guarder observes the client. It composes with a user's existingConfigureClient(running the user hook first, then the guard, so a hook that sets its ownhc.Dialcannot overwrite the guard). Wired intoinit(default client) andWithClientbefore first use, and on the dispatch path only for a per-call variadic client (the global client is already guarded, so the common request path stays lock-free). Clients with a nilConfigureClientare matched by identity and never retained.Because a pooled keep-alive connection is always bound to an already-validated IP, connection reuse cannot be rebound; only a fresh dial reaches a new IP, and every fresh dial runs the guard. The only path left to the caller is a custom
BalancerConfig.Client(*fasthttp.LBClient), whose underlying dialers are not reachable for wrapping — documented onSecurityPolicy.AllowPrivateIPs.As defense-in-depth,
isBlockedIPnow also unwraps IPv4-compatible (::a.b.c.d) and NAT64 (64:ff9b::a.b.c.d) IPv6 literals to the embedded IPv4 and applies the same blocklist (the IPv4-mapped::ffff:form was already covered), so a private target can't be smuggled past as an IPv6 literal.Changes introduced
middleware/proxy/security.go:installHostClientGuard,guardedDial,newGuardedClientDialer,newGuardedClientDialerWithTimeout; hardenisBlockedIPwithembeddedIPv4/allZero; updateSecurityPolicy.AllowPrivateIPsdocs.middleware/proxy/proxy.go:guardConfigureClient/ensureClientGuardedplumbing (mutex-serialized, identity-idempotent), wired intoinit/WithClient/doActionWithPolicy; refresh the per-helper SSRF doc comments and theDomainForwardconstruction-validation note.security_rebinding_test.go,security_test.go):DialTimeout, andDoRedirects; plus a DNS A-query counter asserting the block happens at dial time, not up front.ConfigureClientcompose/ordering + idempotency, a-raceconcurrency regression test, and extendedisBlockedIPcoverage (IPv4-mapped / IPv4-compatible / NAT64, private blocked, public allowed).AllowPrivateIPs=false(mirrors the existingBalancerguard)./docs/changes.Balancer.Type of change
Checklist
go test -race ./middleware/proxy/...,go vet,gofmt).golang.org/x/net/dns/dnsmessage, used only in tests, was already a direct module dependency)./docs/(not applicable — no user-facing docs affected).🤖 Generated with Claude Code